Skip to content

Fixed duplicate staff email when gift members upgrade to paid#27679

Draft
sagzy wants to merge 1 commit intomainfrom
gift-subscriptions/do-not-resend-paid-staff-notification
Draft

Fixed duplicate staff email when gift members upgrade to paid#27679
sagzy wants to merge 1 commit intomainfrom
gift-subscriptions/do-not-resend-paid-staff-notification

Conversation

@sagzy
Copy link
Copy Markdown
Contributor

@sagzy sagzy commented May 5, 2026

closes https://linear.app/ghost/issue/BER-3608

Summary

When a gift member upgrades to a real paid subscription, Ghost was sending staff two near-identical "Paid subscription started" emails:

  1. 🎁 Paid subscription started: <name> — at gift redemption (correct)
  2. 💸 Paid subscription started: <name> — when the member later starts a paid subscription (redundant)

This PR suppresses (2) for the gift→paid path.

Staff already know the member is paying — they were notified at gift redemption. The second email is redundant noise and slightly confusing because it looks like a fresh signup when it isn't.

Changes

Followed the precedent already established for the paid welcome email at member-repository.js:1540, which uses _previousAttributes.status !== 'gift' to skip a duplicate welcome email for previously-gifted members.

The staff notification fires off SubscriptionActivatedEvent. We can't suppress the event itself because gift-service-wrapper also subscribes to it to consume the redeemed gift. So instead:

  1. Carry an isFromGift flag on the event. Set at the dispatch site in member-repository.linkSubscription based on memberModel.get('status') === 'gift' — at that point the member's status field still reflects the pre-update value, the same invariant the welcome-email check relies on one level up the call stack. Both dispatch sites (the incomplete → active 3D-Secure path and the new + immediately active path — the common one for gift→paid) are updated.
  2. Staff service short-circuits when the flag is set. Early-return in staff-service.handleEvent before fetching attribution and calling notifyPaidSubscriptionStarted.
  3. Gift consumption is unchanged. The event still fires; gift-service-wrapper still consumes the gift; only the staff email is suppressed.

This keeps the suppression decision colocated with the data (no extra DB query in the staff service), avoids the race where the gift may already be consumed by the parallel subscriber by the time the staff handler runs, and keeps the staff service decoupled from the gift service.

Tests

  • New unit test in staff-service.test.js: verifies notifyPaidSubscriptionStarted is not called when event.data.isFromGift === true.
  • New unit tests in member-repository.test.js: verify the dispatched SubscriptionActivatedEvent carries isFromGift: true for a status: 'gift' member, and isFromGift: false otherwise.

All existing unit tests still pass (46 staff-service, 54 member-repository), lint clean.

Manual verification

  1. Enable paid_subscription_started_notification for the staff user (default on).
  2. Buy + redeem a gift as a new member — confirm the 🎁 email arrives in Mailpit.
  3. While that member has status = 'gift', complete a paid subscription checkout — confirm no 💸 email arrives.
  4. Verify the gift row is consumed, member is paid, subscription is active.
  5. Sanity check the inverse: a non-gift member subscribing still triggers the 💸 email.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9f1f86b1-bce6-4091-bcbe-4f05f9c98c32

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The changes introduce an isFromGift flag to SubscriptionActivatedEvent set based on whether the member's status is 'gift' at subscription activation time. The flag is computed in two subscription linking code paths. Staff notifications for paid subscription starts are suppressed when isFromGift is true. The event type definition is updated with JSDoc documenting the new flag. Tests verify the flag is correctly set in gift and non-gift scenarios and confirm email notifications are suppressed for gift-originated activations.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing duplicate staff emails when gift members upgrade to paid subscriptions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description clearly explains the issue, implementation, and testing approach, directly matching the code changes across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gift-subscriptions/do-not-resend-paid-staff-notification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sagzy sagzy changed the title 🐛 Fixed duplicate staff email when gift members upgrade to paid Fixed duplicate staff email when gift members upgrade to paid May 5, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f64842d183

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

offerId: offerId,
batchId: options.batch_id
batchId: options.batch_id,
isFromGift: memberModel.get('status') === 'gift'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive isFromGift from pre-transition member status

Using memberModel.get('status') here misses the incomplete → active checkout path for gift redeemers. In that flow, the first linkSubscription call (while Stripe status is incomplete) can already move the member off gift before the later activation webhook runs, so this flag becomes false and StaffService still sends the duplicate paid-subscription-started email the change is meant to suppress. Please compute this from the persisted pre-update status transition data (e.g., the previous status used for welcome-email gating) rather than the current model status at dispatch time.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/core/server/services/staff/staff-service.js (1)

113-117: ⚡ Quick win

Move the gift short-circuit before data loading.

The guard works, but getDataFromIds still runs first (Line 90), so suppressed events still do unnecessary DB reads.

Suggested diff
     async handleEvent(type, event) {
         if (type === MilestoneCreatedEvent && event.data.milestone) {
             await this.emails.notifyMilestoneReceived(event.data);
         }

         if (!['api', 'member'].includes(event.data.source)) {
             return;
         }
+
+        // Suppress for gift→paid upgrades before fetching related models.
+        if (type === SubscriptionActivatedEvent && event.data.isFromGift) {
+            return;
+        }

         const {member, tier, subscription, offer} = await this.getDataFromIds({
             memberId: event.data.memberId,
             tierId: event.data.tierId,
             subscriptionId: event.data.subscriptionId,
             offerId: event.data.offerId
         });

         if (type === MemberCreatedEvent && member.status === 'free') {
             ...
         } else if (type === SubscriptionActivatedEvent) {
-            // Suppress for gift→paid upgrades — staff was already notified
-            // when the member redeemed the gift (notifyGiftSubscriptionStarted).
-            if (event.data.isFromGift) {
-                return;
-            }
             let attribution;
             ...
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/services/staff/staff-service.js` around lines 113 -
117, The early-return for gift-originated events is placed after getDataFromIds,
causing unnecessary DB reads; move the isFromGift guard to run before calling
getDataFromIds so that when event.data.isFromGift is true the function returns
immediately and getDataFromIds is never invoked. Locate the current block
referencing getDataFromIds(...) and the subsequent if (event.data.isFromGift) {
return; } and reorder them so the isFromGift check occurs first, preserving the
existing comment about suppressing gift→paid upgrades.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ghost/core/core/server/services/staff/staff-service.js`:
- Around line 113-117: The early-return for gift-originated events is placed
after getDataFromIds, causing unnecessary DB reads; move the isFromGift guard to
run before calling getDataFromIds so that when event.data.isFromGift is true the
function returns immediately and getDataFromIds is never invoked. Locate the
current block referencing getDataFromIds(...) and the subsequent if
(event.data.isFromGift) { return; } and reorder them so the isFromGift check
occurs first, preserving the existing comment about suppressing gift→paid
upgrades.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d11ea977-0145-48e0-9dcf-e28d3491daa0

📥 Commits

Reviewing files that changed from the base of the PR and between 45490b5 and f64842d.

📒 Files selected for processing (5)
  • ghost/core/core/server/services/members/members-api/repositories/member-repository.js
  • ghost/core/core/server/services/staff/staff-service.js
  • ghost/core/core/shared/events/subscription-activated-event.js
  • ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
  • ghost/core/test/unit/server/services/staff/staff-service.test.js

@sagzy sagzy marked this pull request as draft May 6, 2026 06:53
- staff already receive a "🎁 Paid subscription started" notification when a member redeems a gift, so the follow-up "💸 Paid subscription started" email when that member later starts a real paid subscription is redundant
- carries an `isFromGift` flag on `SubscriptionActivatedEvent`, set at the dispatch site in `member-repository.linkSubscription` based on the member's pre-update status — same precedent the paid welcome email already uses for this case
- staff service short-circuits the email when the flag is set; the gift-service-wrapper subscriber on the same event still runs and consumes the gift as before

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sagzy sagzy force-pushed the gift-subscriptions/do-not-resend-paid-staff-notification branch from 1598936 to 172e393 Compare May 6, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant